社内Slackチャンネルに寄せられた問い合わせのうち特定条件限定にて一覧出力の自動化をやってみた #Slack
社内Slackチャンネルの問い合わせを振り返る際、スクロールしながら件名を確認する手間が大変でした。そのため、Slack APIを使用してスレッド一覧を取得する方法を試みました。
APIを使用してスレッド一覧を取得する方法については、多くのサンプルが存在します。ただし、今回は問い合わせのルールがやや複雑な構成になっているため、完全な自動化はできません。手作業が一定必要な自動化事例として紹介します。
今回のポイント
対象とする問い合わせには、定形フォーマットがあるものとないものの2つがあります。今回取り扱う定形フォーマットはワークフロー経由でのフォーム投稿によるものです。
特定の絵文字が付いている場合にのみ、問い合わせは解決したものとみなします。特定の絵文字が付いていない場合、進捗があっても解決とは見做しません。つまり、解決扱いにする要件と実際の状況には差異が発生しやすいということです。ただ、これまで手作業で行ってきた操作がある程度自動化できるだけでもメリットがあります。
自動化の対象
定形フォーマットがあるものとし、解決と見做せるものは特定の絵文字がついている場合のみとしました。
問い合わせに定形フォーマットがない場合は、問い合わせ内容や質問か相談かの判断が必要となります。AI判定のChatGPTなどが使用できれば良いのですが、今回の環境では不可能であるため、対象から除外しました。
絵文字が付いていない場合の問い合わせ解決の判定条件には、リプライに謝辞が含まれていることなどが挙げられます。ただし、これらをコーディングによって判定することは揺らぎ幅が大きく、課題となります。AI判定を用いる方法もありますが、前述の理由で除外しています。
実装
API呼び出し部分の初版はChatGPT3によって生成されました。ChatGPT3によって生成されたコードは、古いバージョンのAPIを元にした内容を出力したため、最新のリファレンスを元に修正しました。
const developSlackToken = "xoxp-XXXXXXXXXXXX-XXXXXXXXXXXXXXXX-XXXXXXXXXXXX"; const productSlackToken = "xoxp-XXXXXXXXXXXX-XXXXXXXXXXXXXXXX-XXXXXXXXXXXX"; const developSlackChannel = "XXXXXXXXXXX"; const productSlackChannel = "XXXXXXXX"; const product_host = "example-product.slack.com" const develop_host = "example-test.slack.com" // false=本番, true=開発 const dev_mode = false function makePermalink(ts, is_dev=true) { if (!ts) return; var _ts = ts.split(".").join("") var _host = is_dev ? develop_host : product_host return "https://" + _host + "/archives/" + pickSlackChannel(is_dev) + "/p" + _ts } const FAQ_NOT_TOUCH = 1.0 const FAQ_PROGRESS = 2.0 const FAQ_FINISH = 3.0 function pickCurrentStatus(message) { if (!message) return var _has_reaction = Object.keys(message).indexOf("reactions") > -1 var _has_reply = Object.keys(message).indexOf("reply_count") > -1 // reactionついてたりリプライついたりしたら着手開始してるとみなす var _status = _has_reaction || _has_reply ? FAQ_PROGRESS : FAQ_NOT_TOUCH // 「詳細はリプライで」をやる人が偶にいるので、reaction無しでリプライユーザが起案者のみの場合は未着手のままにする if (_status === FAQ_PROGRESS && ! _has_reaction && _has_reply) { if (message.reply_users.map(reply_user => message.text.indexOf(reply_user) > -1).indexOf(false) < 0) { _status = FAQ_NOT_TOUCH } } //コンプチェック。リプライなしで済つくことも考えてリアクションだけで判断する。 if (Object.keys(message).indexOf("reactions") > -1) { _status = message.reactions.map(reaction => reaction.name === "zumi").indexOf(true) > -1 ? FAQ_FINISH : _status; } var _label = "未対応" switch (_status) { case FAQ_PROGRESS: _label = "処理中" break; case FAQ_FINISH: _label = "完了" break; } return _label } function pickSlackChannel(is_dev=true) { return is_dev ? developSlackChannel : productSlackChannel; } function pickSlackToken(is_dev=true) { return is_dev ? developSlackToken : productSlackToken; } function getInquiry(startDate, endDate, is_dev=true) { if (!startDate || !endDate) return; var startTimestamp = new Date(startDate).getTime() / 1000; var endTimestamp = new Date(endDate).getTime() / 1000 + 86399; // 23:59:59の秒数を追加 var apiUrl = "https://slack.com/api/conversations.history?" + "channel=" + pickSlackChannel(is_dev) + "&oldest=" + startTimestamp + "&latest=" + endTimestamp; const options = {"headers": { 'Authorization': 'Bearer ' + pickSlackToken(is_dev) }}; var response = UrlFetchApp.fetch(apiUrl, options); var responseData = JSON.parse(response); var result = []; if (responseData.ok) { var messages = responseData.messages; for (var i = 0; i < messages.length; i++) { var _message = messages[i]; if (!_message) continue; if (_message.type != "message") continue; if (_message.subtype != "bot_message") continue; if (!Object.keys(_message).indexOf("bot_id") < 0) continue; // Workflowのbot_idは調べるのが中々手間なので名前を変えない前提にて if (!Object.keys(_message).indexOf("username") < 0) continue; if (_message.username != "問い合わせ") continue; if (!_message.blocks[0].text.text.indexOf("問い合わせフォーム") < 0) continue; var title = _message.blocks[3].text.text.split("\n")[1] var link = makePermalink(_message.ts, is_dev) var status = pickCurrentStatus(_message) result.push([_message.ts, link, title, status]) } } return result; } function getSlackTickets() { var input = Browser.inputBox('取得するチケットの最終更新日', '4と入力すると、最終更新日が4日前以降のチケットを取得します', Browser.Buttons.OK_CANCEL); if (input === 'cancel') { Logger.log('キャンセルされました。'); return; } var today = new Date() var _sDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()) _sDate.setDate(_sDate.getDate() - Number(input)); var startDate = Utilities.formatDate(_sDate, 'JST', 'yyyy-MM-dd'); var _eDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()) _eDate.setDate(_eDate.getDate() + 1); var endDate = Utilities.formatDate(_eDate, 'JST', 'yyyy-MM-dd'); var threads = getInquiry(startDate, endDate, dev_mode); var ss = SpreadsheetApp.getActiveSheet(); var values = threads.map(function (info) { return ["=HYPERLINK(\"" + info[1] + "\", \"" + info[0] + "\")", info[2], info[3]]; }); ss.getRange(1, 1, values.length, 3).setValues(values); }
APIレスポンス処理のデバッグ中に度々エラーとなった箇所としては、reactions
やreply_count
等の値が無ければ生成されないプロパティでした。都度以下のような形で判定を入れています。
Object.keys(message).indexOf("reactions")
ワークフロー起案者によるリプライ判定については、ワークフロー投稿のパラメータ構成には起案者のIDが含まれないため、起案で投稿される自動定形文の一部に含まれる起案者のIDを利用しています。
実行結果
以下のような構成で出力されます。左カラムのリンクにて実際のポストへアクセスできます。
あとがき
条件に一致した問い合わせのみを対象とし、それ以外の問い合わせは人力で対応します。この方法は、総件数が多くないことが前提となっているため、1日に2桁や3桁の問い合わせが舞い込む場合には適していません。
完全にはできませんが、人力でカバーする範囲をさらに抑えるために、より良い手段も模索していきたいところです。